Modelling wind ripples
Here, I try to simulate the patterns obtained on a sandy terrain. This is what is observed when the wind blows on the dry surface of a land with sand or also on the surface of the sea in the presence of currents.
As described in https://en.wikipedia.org/wiki/Dune,
When a sandy seabed is subject to wave action and the wave orbital motion is strong enough to move sand grains, ripples often appear. The ripples induced by wave action are called “wave ripples”; their characteristics being different from those of the ripples generated by steady flows. The most striking difference between wave ripple fields and current ripple fields is the regularity of the former. Indeed, regular long-crested wave ripple fields are often observed on tidal beaches from which the sea has withdrawn at low water (see figure 1).
A nice example is shown in http://www.coastalwiki.org/wiki/Wave_ripple_formation showing
Ripples observed at Sea Rim State Park, along the coast of east Texas close to the border with Louisiana (courtesy by Zoltan Sylvester).

An interesting aspect of that patterns is that they may occur at different scales, like taht example on the surface of the Mars planet:
Overview of large sand wave field and high-resolution difference map of two surveys approximately 21 hours apart illustrating both large-scale and small-scale sand wave migration and orientation. Migration is from right to left.

Let's first initialize the notebook:
import matplotlib
import numpy as np
np.set_printoptions(precision=6, suppress=True)
import os
%matplotlib inline
#%config InlineBackend.figure_format='retina'
%config InlineBackend.figure_format = 'svg'
import matplotlib.pyplot as plt
fig_width = 13
subplotpars = matplotlib.figure.SubplotParams(left=0., right=1., bottom=0., top=1., hspace=0., wspace=0.05,)
# Importing libraries
import torch
import torch.nn.functional as nnf
torch.manual_seed(1998)
torch.set_printoptions(precision=3, linewidth=140, sci_mode=False)
if torch.backends.mps.is_available():
device = torch.device('mps')
elif torch.cuda.is_available():
device = torch.device('cuda')
else:
device = torch.device('cpu')
torch.__version__, device
Wave & wind ripples¶
Such ripples can be charcterized using the physical process that generate them:
Sea waves shape the bottom and generate different morphological patterns, which are characterized by a wide range of length scales. The ripples are the smallest bedforms but, notwithstanding their relatively small size, they play a prominent role in many transport processes. Indeed, usually, the flow separates at their crests and vortices are generated which increase momentum transfer, sediment transport and, in general, mixing phenomena. (from http://www.coastalwiki.org/wiki/Wave_ripples)
And can be formalized using simple iterative process like that described in http://nishitalab.org/user/nis/cdrom/pg/onoue.pdf or a more complex example shown in https://www.hindawi.com/journals/jam/2014/590358/
We will try to give a simple formulation and first initialize the parameters:
from dataclasses import dataclass, asdict
DEBUG = 2
DEBUG = 1
@dataclass
class Params:
bins: int = 2**(8//DEBUG)
N_particles: int = 2**(8*2) * 4
N_step: int = 2**(10//DEBUG)
L: float = 1 / 2**6 # saltation step in the y direction
kappa: float = 4.0
D: float = 5.e-4 # diffusion per step
seed : int = 1998
p = Params()
p
The dynamical system can be formalized by the following equations.
Diffusion step with $\nu$ a normal noise: $$ \vec{x}_i \leftarrow \vec{x}_i + D \cdot \nu $$ where $\vec{x}_i = (x_i, y_i)$ corresponds to the position of the $i$-th particle.
We may compute the local height as the density of particle: $$ h(\vec{x}) = \# \{ \vec{x}_i=\vec{x} | 1 \le i \le N \} $$ This is in practice defined on a local grid and with a finite number $N$ of particles.
We may finally define a saltation step in the wind direction direction: $$ y_i \leftarrow y_i + L \cdot \sigma( - \kappa * h(\vec{x})) $$ where $\sigma$ is the sigmoid function $\sigma(x) = \frac 1 {1 + \exp(-x)} $. For computational tractability, coordinates are finally remapped on the torus.
Interestingly, this type of model combining a linear diffusion and a non-linear scaling is found in many different physical systems, including neural systems (random recurrent neural fields for instance) and has a long history in computational neuroscience. The implementation we chose is slightly different as the field is defined by particles, reminiscent of our previous work on modelling motion detection in the visual system.
The dynamical system can be simplified in the following code which models the terrain by sand particles and then estimates the terrain using a spatial histogram. First, we code it in numpy:
def sigmoid(x):
return 1 / (1 + np.exp(-x))
def wind_ripples(p):
binz = [np.linspace(0, 1, p.bins+1, endpoint=True), np.linspace(0, 1, p.bins+1, endpoint=True)]
np.random.seed(p.seed)
x, y = np.random.rand(p.N_particles), np.random.rand(p.N_particles)
for i_step in range(p.N_step):
x, y = x + p.D/np.sqrt(i_step+1)*np.random.randn(p.N_particles), y + p.D/np.sqrt(i_step+1)*np.random.randn(p.N_particles)
x, y = x % 1, y % 1
height, edge_x, edge_y = np.histogram2d(x, y, bins=binz, density=True)
ind_x, ind_y = (x*p.bins).astype(int), (y*p.bins).astype(int)
y += p.L * sigmoid( - p.kappa * height[ind_x, ind_y])
return height
Which runs for N_step=2**8 on the CPU in about 12s:
%timeit -n1 -r1 wind_ripples(Params(N_step=2**8))
Then using pyTorch:
def wind_ripples(p):
with torch.no_grad():
binz = (torch.linspace(0, 1, p.bins+1), torch.linspace(0, 1, p.bins+1))
np.random.seed(p.seed)
pos = torch.rand(p.N_particles, 2)
for i_step in range(p.N_step):
# pos += p.D/torch.sqrt(torch.tensor(i_step+1))*torch.randn(p.N_particles, 2)
pos += p.D*torch.randn(p.N_particles, 2)
pos = pos % 1
# https://pytorch.org/docs/stable/generated/torch.histogramdd.html
height, edge_pos = torch.histogramdd(pos, bins=binz, density=True)
ind_pos = torch.ceil(pos*p.bins).long() - 1
pos[:, 1] += p.L * torch.sigmoid( - p.kappa * height[ind_pos[:, 0], ind_pos[:, 1]])
return height.numpy()
Which runs for the same simulation on the GPU in about 2.5s:
%timeit -n1 -r1 wind_ripples(Params(N_step=2**8))
Which runs 5x faster (tested on a MacOS macbookpro with silicon M1).
Running the dynamical system finally produces a pattern ressembling indeed wind ripples:
height = wind_ripples(Params())
fig, ax = plt.subplots(figsize=(fig_width, fig_width), subplotpars=subplotpars)
ax.imshow(height.T, vmin=0, vmax=height.max(), cmap=plt.copper())
ax.axis('off');
Let's try to modify some parameters to see their effect on the obtained patterns:
N_scan = 4
def scan(variable, values):
print(f'Scanning {variable=} along {values=}')
N_scan_ = len(values)
fig, axs = plt.subplots(1, N_scan_, figsize=(fig_width, fig_width/N_scan_), subplotpars=subplotpars)
for i_scan, value in enumerate(values):
scan_dict = {variable: value}
p = Params(**scan_dict)
print(i_scan, 'params', p)
height = wind_ripples(p)
ax = axs[i_scan]
ax.imshow(height.T, cmap=plt.copper())
formatted_value = value if (type(value) == int) else f'{values[i_scan]:.2e}'
ax.set_title(f'{variable}={formatted_value}')
ax.axis('off')
return fig, axs
First, kappa ($\kappa$) controls the slope of the sigmoid, and leads to different patterns, the system bifurcating quickly from one set of pattern to the other :
fig, axs = scan('kappa', p.kappa * np.logspace(-1, 1, N_scan, base=5));
Only a limited set of parameters leads to interesting patterns, and we observe a transition from sparse (isolated) to more dense (packed) ripples:
fig, axs = scan('kappa', p.kappa * np.logspace(-1, 1, N_scan, base=2));
The diffusion parameter $D$ has also a striking effect, as it controls in some sort the "viscosity" of the sand grains, the ripples disappearing when too much diffusion occurs:
fig, axs = scan('D', p.D * np.logspace(-1, 1, N_scan, base=10));
By "zooming" into this range of parameter, we observe a quite smooth transition :
fig, axs = scan('D', p.D * np.logspace(-1, 1, N_scan, base=4));
We may further explore the effect of simulation parameters, respctively the number of simulation steps N_Steps:
fig, axs = scan('N_step', 2**np.arange(4, 4*N_scan, 3, dtype=int));
the numbers of bins used to compute the height:
fig, axs = scan('bins', 2**np.arange(6, N_scan+6, dtype=int));
the number of particles, showing that the simulations are stable given a sufficient number of particles:
fig, axs = scan('N_particles', (p.bins)**2 * 2**np.arange(1, N_scan+1, dtype=int));
or the saltation length:
fig, axs = scan('L', np.geomspace(1 / 2**7, 1 / 2**2, N_scan));
Genrating dune ripples¶
Dune ripples involve a slightly more complex model which can be defined as the following
@dataclass
class Params:
bins: int = 2**(8//DEBUG)
N_particles: int = 2**(8*2) * 4
N_step: int = 2**(10//DEBUG)
L: float = 1 / 2**6 # saltation step in the y direction
D: float = 2.e-3 # diffusion per step
b: float = 8.e-2
kappa: float = 10.
seed : int = 1998
p = Params()
p
The dynamical system can be formalized as the previous equations, but adding a saltation which depends on the slope.
$$ y_i \leftarrow y_i + L + b \cdot (2\sigma( - \kappa * \nabla_y h(\vec{x}))-1) $$where $\nabla_y$ is the slope in the $y$ direction (that is, the gradient of the height in that direction).
First using numpy on the CPU:
def gradient_y(image):
# image_ = image.copy()
grad = np.roll(image, shift=-1, axis=1)
grad -= np.roll(image, shift=1, axis=1)
return grad
def dune_ripples(p):
binz = [np.linspace(0, 1, p.bins+1, endpoint=True), np.linspace(0, 1, p.bins+1, endpoint=True)]
np.random.seed(p.seed)
x, y = np.random.rand(p.N_particles), np.random.rand(p.N_particles)
for i_step in range(p.N_step):
x, y = x + p.D/(i_step+1)*np.random.randn(p.N_particles), y + p.D/(i_step+1)*np.random.randn(p.N_particles)
x, y = x % 1, y % 1
height, edge_x, edge_y = np.histogram2d(x, y, bins=binz, density=True)
slope = gradient_y(height)
ind_x, ind_y = (x*p.bins).astype(int), (y*p.bins).astype(int)
y += p.L + p.b * np.tanh( - p.kappa * slope[ind_x, ind_y])
return height, slope
# height, slope = dune_ripples(Params())
Then using pyTorch:
def gradient_y(image):
# image_ = image.copy()
grad = torch.roll(image, shifts=(0, -1), dims=(0, 1))
grad -= torch.roll(image, shifts=(0, 1), dims=(0, 1))
return grad
def dune_ripples(p):
with torch.no_grad():
binz = (torch.linspace(0, 1, p.bins+1), torch.linspace(0, 1, p.bins+1))
np.random.seed(p.seed)
pos = torch.rand(p.N_particles, 2)
for i_step in range(p.N_step):
# pos += p.D/torch.sqrt(torch.tensor(i_step+1))*torch.randn(p.N_particles, 2)
pos += p.D*torch.randn(p.N_particles, 2)
pos = pos % 1
# https://pytorch.org/docs/stable/generated/torch.histogramdd.html
height, edge_pos = torch.histogramdd(pos, bins=binz, density=True)
slope = gradient_y(height)
ind_pos = torch.ceil(pos*p.bins).long() - 1
pos[:, 1] += p.L * torch.sigmoid( - p.kappa * height[ind_pos[:, 0], ind_pos[:, 1]])
pos[:, 1] += p.b * torch.tanh( - p.kappa * slope[ind_pos[:, 0], ind_pos[:, 1]])
return height.numpy(), slope.numpy()
height, slope = dune_ripples(p = Params())
The system leads to a very resembling pattern which has more structural complexity with the wind ripples that we generated above:
fig, ax = plt.subplots(figsize=(fig_width, fig_width), subplotpars=subplotpars)
ax.imshow(height.T, cmap=plt.copper())
ax.axis('off');
def scan(variable, values):
print(f'Scanning {variable=} along {values=}')
N_scan_ = len(values)
fig, axs = plt.subplots(1, N_scan_, figsize=(fig_width, fig_width/N_scan_), subplotpars=subplotpars)
for i_scan, value in enumerate(values):
scan_dict = {variable: value}
p = Params(**scan_dict)
print(i_scan, 'params', p)
height, slope = dune_ripples(p)
ax = axs[i_scan]
ax.imshow(height.T, cmap=plt.copper())
formatted_value = value if (type(value) == int) else f'{values[i_scan]:.2e}'
ax.set_title(f'{variable}={formatted_value}')
ax.axis('off')
return fig, axs
the dune-like patterns can be observed for a set of parameters:
fig, axs = scan('kappa', p.kappa * np.logspace(-1, 1, N_scan, base=13));
fig, axs = scan('kappa', p.kappa * np.logspace(-1, 1, N_scan, base=5));
The diffusion pattern seems to control the frequency and length of the stripes, with quite abrupt bifurcations:
fig, axs = scan('D', p.D * np.logspace(-1, 1, N_scan, base=20));
fig, axs = scan('D', p.D * np.logspace(-1, 1, N_scan, base=4));
While b controls the stability
fig, axs = scan('b', p.b * np.logspace(-1, 1, N_scan, base=21));
and interestingly, the frequency may decrease and increase again:
fig, axs = scan('b', p.b * np.logspace(-1, 1, N_scan, base=5));
fig, axs = scan('b', p.b * np.logspace(-1, 1, N_scan, base=3));
fig, axs = scan('L', np.geomspace(1 / 2**7, 1 / 2**2, N_scan));
Similarly, we may explore the effect of simulation parameters:
fig, axs = scan('N_step', 2**np.arange(4, 4*N_scan, 3, dtype=int));
fig, axs = scan('bins', 2**np.arange(5, N_scan+6, dtype=int));
fig, axs = scan('N_particles', (p.bins)**2 * 2**np.arange(1, N_scan+1, dtype=int));
Conclusion¶
As a conclusion, we could easily generate ripple and dune patterns. They form some class of reaction diffusion system, where the reaction is the accumulation of sand and the diffusion is the stronger effect of wind on differential areas of the pattern. We provide an implementation on numpy and a faster one using pyTorch.
We have provided an original implementation combining a particle-based representation with a dense interaction map allowing to generate maps in a few seconds. Future extensions should study and more thoroughly the bifurcation maps, for instance by showing the evolution of the spectrum in each direction as a function of the parameters, and -why not- spend some time predicting them analytically :-)
Appendix: version of the libraries that were used¶
%load_ext watermark
%watermark -i -h -m -v -p numpy,torch,matplotlib -r -g -b